/* ***** BEGIN LICENSE BLOCK *****
 * Version: MPL 1.1/GPL 2.0/LGPL 2.1
 *
 * The contents of this file are subject to the Mozilla Public License Version
 * 1.1 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 * http://www.mozilla.org/MPL/
 *
 * Software distributed under the License is distributed on an "AS IS" basis,
 * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
 * for the specific language governing rights and limitations under the
 * License.
 *
 * The Original Code is ThumbsDown.
 *
 * The Initial Developer of the Original Code is Atte Kemppilä.
 * Portions created by the Initial Developer are Copyright (C) 2011
 * the Initial Developer. All Rights Reserved.
 *
 * Contributor(s):
 *
 * Alternatively, the contents of this file may be used under the terms of
 * either the GNU General Public License Version 2 or later (the "GPL"), or
 * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
 * in which case the provisions of the GPL or the LGPL are applicable instead
 * of those above. If you wish to allow use of your version of this file only
 * under the terms of either the GPL or the LGPL, and not to allow others to
 * use your version of this file under the terms of the MPL, indicate your
 * decision by deleting the provisions above and replace them with the notice
 * and other provisions required by the GPL or the LGPL. If you do not delete
 * the provisions above, a recipient may use your version of this file under
 * the terms of any one of the MPL, the GPL or the LGPL.
 *
 * ***** END LICENSE BLOCK ***** */


const EXPORTED_SYMBOLS = ["Sqlite"];

const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;

Cu.import("resource://gre/modules/Services.jsm");


/**
 * @param {String} options.filename  database filename under profile directory (ProfD)
 * @param {String} options.schemaVersion  database schemaVersion
 * @param {String} options.schemaScript  database schema script url
 * @param {String} options.migrateScript  database migration script url. '$' in url replaced with current schema version.
 * @param {Object} options.stmt  map of sql statements {name: sql}
 */
function Sqlite(options)
{
    this.options = options;
}

Sqlite.prototype =
{
    open: function()
    {
        if (this.db)
            return;
        
        this.db = SqliteFactory.openDatabase(this.options);
        
        for (let [name, sql] in Iterator(this.options.stmt))
            this[name] = SqliteFactory.createStatement(this.db, sql);
    },
    
    close: function()
    {
        if (! this.db)
            return;
        
        for (let [name, stmt] in Iterator(this))
            if ((typeof(stmt) == "function") && (stmt.name == "sqliteStatement"))
            {
                stmt().finalize();
                delete this[name];
            }
        
        this.db.close();
        delete this.db;
    },
    
    tx: function(obj, func)
    {
        this.db.beginTransaction();
        try
        {
            let result = func.call(obj);
            this.db.commitTransaction();
            return result;
        }
        catch (e)
        {
            this.db.rollbackTransaction();
            throw e;
        }
    },
}


const SqliteFactory =
{
    /**
     * @param {String} options.filename  database filename under profile directory (ProfD)
     * @param {String} options.schemaVersion  database schemaVersion
     * @param {String} options.schemaScript  database schema script url
     * @param {String} options.migrateScript  database migration script url. '$' in url replaced with current schema version.
     */
    openDatabase: function(options)
    {
        let dbFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
        dbFile.append(options.filename);
        let exists = dbFile.exists();
        let db = Services.storage.openDatabase(dbFile);
        
        if (! db.connectionReady)
            throw new Error("db connection is not ready");
        
        db.beginTransaction();
        try
        {
            if (! exists)
            {
                this._createSchema(db, options);
            }
            else if (db.schemaVersion < options.schemaVersion)
            {
                this._backupDatabase(db, options);
                this._migrateSchema(db, options);
            }
            
            if (db.schemaVersion != options.schemaVersion)
                throw new Error("unexpected db schema version " + db.schemaVersion + ", expected " + options.schemaVersion);
            
            db.commitTransaction();
            
            return db;
        }
        catch (e)
        {
            db.rollbackTransaction();
            db.close();
            
            if (! exists)
                dbFile.remove(false);
            
            throw e;
        }
    },
    
    /**
     * @param {mozIStorageConnection} db database connection
     * @param {String} sqlStatement sql statement
     */
    createStatement: function(db, sqlStatement)
    {
        let stmt = new SqliteStatement(db, sqlStatement);
        
        return function sqliteStatement()
        {
            let args = arguments;
            return {__noSuchMethod__: function(name) { return stmt[name].apply(stmt, args); } };
        };
    },
    
    _createSchema: function(db, options)
    {
        let sqlStatements = this._loadSql(options.schemaScript);
        this._execSql(db, sqlStatements);
    },
    
    _migrateSchema: function(db, options)
    {
        while (db.schemaVersion < options.schemaVersion)
        {
            let prevVersion = db.schemaVersion;
            
            let url = options.migrationScript.replace("$", db.schemaVersion);
            let sqlStatements = this._loadSql(url);
            this._execSql(db, sqlStatements);
            
            // make sure that the migration script incremented the schema version
            if (db.schemaVersion <= prevVersion)
                throw new Error("db schema version not incremented: " + prevVersion + " -> " + db.schemaVersion + "(" + url + ")");
        }
    },
    
    _backupDatabase: function(db, options)
    {
        let dbFile = db.databaseFile;
        let dbFilename = dbFile.leafName;
        let backupFilename = dbFilename + "-" + db.schemaVersion + ".bak";
        let backupFile = Services.storage.backupDatabaseFile(dbFile, backupFilename);
    },
    
    _execSql: function(db, sqlStatements)
    {
        for each (let sqlStatement in sqlStatements)
            db.executeSimpleSQL(sqlStatement);
    },
    
    _loadSql: function(url)
    {
        let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
        xhr.overrideMimeType("text/plain");
        xhr.open("GET", url, false);
        xhr.send(null);
        
        let sqlStatements = xhr.responseText.replace(/--.*/g, "").replace(/\n+/g, " ").replace(/\s{2,}/g, " ").split(";");
        sqlStatements = [s.trim() for each (s in sqlStatements)];
        sqlStatements = [s for each (s in sqlStatements) if (s.length > 0)];
        return sqlStatements;
    },
}


/**
 * @param {mozIStorageConnection} db  db connection
 * @param {String} sqlStatement  sql statement
 */
function SqliteStatement(db, sqlStatement)
{
    try
    {
        this.db = db;
        this.stmt = db.createStatement(sqlStatement);
    }
    catch (e)
    {
        throw new Error("failed to create sql statement: " + sqlStatement + ". Cause: " + e.message);
    }
}

SqliteStatement.prototype =
{
    execute: function()
    {
        try
        {
            this._bindParams.apply(this, arguments);
            this.stmt.execute();
            return this.db.lastInsertRowID;
        }
        finally
        {
            this.stmt.reset();
        }
    },
    
    getOne: function()
    {
        let result = this.getAll.apply(this, arguments);
        
        switch (result.length)
        {
            case 0: return null;
            case 1: return result[0];
            default: throw new Error("too many results: " + result.length);
        }
    },
    
    getAll: function()
    {
        try
        {
            this._bindParams.apply(this, arguments);
            let records = [];
            
            if (this.stmt.columnCount == 1)
            {
                while (this.stmt.executeStep())
                    records.push(this.stmt.row[this.stmt.getColumnName(0)]);
            }
            else
            {
                while (this.stmt.executeStep())
                {
                    let record = {};
                    
                    // row doesn't support enumeration - https://bugzilla.mozilla.org/show_bug.cgi?id=489897
                    for (let i = 0; i < this.stmt.columnCount; i++)
                    {
                        let name = this.stmt.getColumnName(i);
                        record[name] = this.stmt.row[name];
                    }
                    
                    records.push(record);
                }
            }
            
            return records;
        }
        finally
        {
            this.stmt.reset();
        }
    },
    
    finalize: function()
    {
        this.stmt.finalize();
    },
    
    _bindParams: function(params)
    {
        if (arguments.length == 0)
            return;
        
        // if param is a simple scalar value, bind it to the default param "value"
        if (Object.prototype.toString.call(params) != "[object Object]")
            params = {value: params};
        
        // mozIStorageStatementParams can't handle undefined
        for (let name in this.stmt.params)
            this.stmt.params[name] = ((params[name] != undefined) ? params[name] : null);
    },
}
